Skip to content

Conversation

@Jopo-JP
Copy link

@Jopo-JP Jopo-JP commented Feb 9, 2026

Summary

This PR introduces a --watch (-w) flag to the view command, significantly improving the Developer Experience (DX) by allowing real-time monitoring of specifications and changes without manual refreshes.

Problem

Currently, users must repeatedly execute openspec view to check the status of their specs or track progress on changes. This breaks flow and makes it difficult to visualize updates as they happen.

Solution

I implemented a robust watch mode that refreshes the dashboard automatically. Key implementation details include:

  • Smart Rendering: The dashboard is only repainted when the underlying data actually changes. This eliminates the annoying "flicker" common in simple CLI loops and keeps the terminal scrollback clean.
  • Full Fidelity: All existing ANSI colors, formatting, and layout from the standard view command are strictly preserved.
  • Graceful Handling: The process handles SIGINT (Ctrl+C) correctly, ensuring the cursor is restored and the process exits cleanly.
  • Efficient Polling: Uses a 2-second polling interval which strikes a balance between responsiveness and resource usage.

Test Plan

  • Unit Tests: Added comprehensive tests in test/core/view.test.ts to verify the watch logic and ensure the draft/active/completed sorting remains deterministic.
  • Manual Verification:
    1. Run npm run dev:cli -- view --watch
    2. Modify a spec or task file in another terminal.
    3. Verify the dashboard updates instantly without flickering.
    4. Scroll up/down in the terminal (updates should not forcibly scroll you to the top unless content changes).

Summary by CodeRabbit

  • New Features

    • Added --watch (-w) flag to the view command for real-time monitoring
    • View now refreshes every 2 seconds in watch mode and only updates when content changes
    • Graceful exit handling when leaving watch mode; non-watch behavior unchanged
  • Tests

    • Added/updated tests to cover watch mode and consolidated output assertions

@Jopo-JP Jopo-JP requested a review from TabishB as a code owner February 9, 2026 13:35
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 9, 2026

📝 Walkthrough

Walkthrough

Adds a --watch (-w) option to the "view" command and implements a watch/real-time rendering mode in the ViewCommand that refreshes periodically, re-renders only on content changes, and supports graceful shutdown via SIGINT/AbortSignal. Rendering was refactored to produce string output.

Changes

Cohort / File(s) Summary
CLI Watch Option
src/cli/index.ts
Adds --watch (-w) flag to the view command and forwards { watch } to ViewCommand.execute.
Core View Command
src/core/view.ts
Extends execute signature to execute(path, { watch?: boolean; signal?: AbortSignal } = {}). Adds watch loop (2s interval), conditional re-rendering on content diff, screen clear, SIGINT/AbortSignal handling, and refactors console logging into string-returning helpers (getDashboardOutput, getSummaryOutput).
Tests (watch coverage + logging)
test/core/view.test.ts
Unifies log aggregation into a single output buffer for multi-call logging, adapts assertions, and adds a watch-mode test using AbortController.
Manifest
package.json
Small manifest edits (lines changed +3/-2).

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant CLI
    participant ViewCommand
    participant Terminal

    User->>CLI: run `view` --watch
    CLI->>ViewCommand: execute(path, { watch: true })
    ViewCommand->>ViewCommand: initial getDashboardOutput()/getSummaryOutput()
    ViewCommand->>Terminal: render initial dashboard

    loop every 2s
        ViewCommand->>ViewCommand: regenerate dashboard string
        alt content changed
            ViewCommand->>Terminal: clear screen
            ViewCommand->>Terminal: print new dashboard
        else no change
            Note over ViewCommand,Terminal: skip reprint
        end
    end

    User->>ViewCommand: SIGINT / AbortSignal
    ViewCommand->>Terminal: stop loop and exit gracefully
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I nibble lines and stitch them bright,
A dashboard hums in gentle night,
Two-second hops to catch new sight,
I clear the screen and show what's right —
Ctrl+C, I bow, and sleep all tight. ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately and concisely summarizes the primary change: adding a --watch flag to the view command, which is the central feature across all modified files (CLI, core logic, and tests).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Feb 9, 2026

Greptile Overview

Greptile Summary

This PR adds a --watch/-w flag to openspec view and refactors the view rendering into a string-producing path so it can be periodically re-rendered without flicker. In watch mode, ViewCommand.execute() polls every 2s, recomputes the dashboard output, and only clears/repaints the terminal when the rendered content changes; non-watch mode prints the rendered output once.

Key areas touched:

  • src/cli/index.ts wires the new flag into the CLI and forwards it to ViewCommand.
  • src/core/view.ts adds watch-mode looping + getDashboardOutput() / getSummaryOutput() helpers.
  • test/core/view.test.ts updates assertions to account for consolidated output.

Confidence Score: 4/5

  • Mostly safe to merge, but there are a couple correctness/robustness issues to address first.
  • Core functionality is straightforward and isolated, but watch mode currently registers a persistent SIGINT listener with process.on and never removes it (can accumulate listeners in long-lived processes), and the updated tests use raw output for substring assertions while separately stripping ANSI codes, which can make them fail in color-enabled environments.
  • src/core/view.ts, test/core/view.test.ts

Important Files Changed

Filename Overview
src/cli/index.ts Adds --watch/-w option to view and forwards it into ViewCommand.execute().
src/core/view.ts Implements watch mode by periodically re-rendering dashboard output; introduces a persistent SIGINT handler that isn’t removed (listener accumulation risk).
test/core/view.test.ts Updates tests to handle single-call logging; introduces inconsistent ANSI stripping in some assertions that can make tests flaky in color-enabled environments.

Sequence Diagram

sequenceDiagram
  participant U as User
  participant CLI as src/cli/index.ts (commander)
  participant V as ViewCommand
  participant FS as File system
  participant T as Task progress utils

  U->>CLI: openspec view --watch
  CLI->>V: execute(targetPath, {watch:true})
  loop every 2s
    V->>FS: read openspec/changes + openspec/specs
    V->>T: getTaskProgressForChange(...)
    V-->>V: getDashboardOutput()
    alt output changed
      V->>CLI: stdout.write(clear + output)
    else unchanged
      V-->>CLI: no write
    end
  end
  U->>CLI: Ctrl+C (SIGINT)
  CLI->>V: SIGINT handler
  V->>V: clearInterval + exit(0)
Loading

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

src/core/view.ts Outdated
Comment on lines 39 to 43
process.on('SIGINT', () => {
clearInterval(interval);
console.log('\nExiting watch mode...');
process.exit(0);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SIGINT handler leaks

process.on('SIGINT', ...) is registered inside execute() every time watch mode runs, but it’s never removed. In a long-lived process (or if ViewCommand.execute() is invoked multiple times in the same Node process, e.g. tests/embedders), this accumulates listeners and can trigger MaxListenersExceededWarning / multiple exit handlers firing. Prefer process.once('SIGINT', ...) or remove the listener when clearing the interval.

Comment on lines 56 to +59
// Draft section should contain empty and no-tasks changes
expect(output).toContain('Draft Changes');
expect(output).toContain('empty-change');
expect(output).toContain('no-tasks-change');
expect(allOutput).toContain('Draft Changes');
expect(allOutput).toContain('empty-change');
expect(allOutput).toContain('no-tasks-change');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ANSI stripping mismatch

These assertions use allOutput (raw, potentially ANSI-colored) while the new lines array is stripAnsi’d. If chalk color output is enabled in the test environment, expect(allOutput).toContain('Draft Changes') / similar can fail due to escape codes splitting the plain text. Use the stripped version (lines.join('\n') or stripAnsi(allOutput)) consistently for substring assertions.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/core/view.ts`:
- Around line 29-31: The catch in the update loop (where getDashboardOutput is
called) only logs to stderr so the stale dashboard remains visible; change the
catch in src/core/view.ts to clear or overwrite the painted dashboard and print
the error prominently to stdout (use the same screen-clearing helper used
elsewhere or write \u001b[2J\u001b[0;0H to stdout) and include the error message
styled with chalk; additionally add a simple retry/failure counter (e.g.,
failedUpdateCount) inside the updater function to exit or stop retrying after N
consecutive failures to avoid spamming stderr when getDashboardOutput repeatedly
throws.
🧹 Nitpick comments (6)
src/core/view.ts (5)

39-43: SIGINT listener is never removed and could leak in tests.

process.on('SIGINT', ...) registers a permanent listener. If execute is ever called more than once (e.g., in a test harness), listeners accumulate. Consider using process.once('SIGINT', ...) or storing the handler and calling process.removeListener alongside clearInterval.

Proposed fix
-      process.on('SIGINT', () => {
+      const onSigint = () => {
         clearInterval(interval);
         console.log('\nExiting watch mode...');
         process.exit(0);
-      });
+      };
+      process.once('SIGINT', onSigint);

46-46: await new Promise(() => {}) blocks forever — intentional but fragile.

This is the standard "keep-alive" pattern for CLI watch loops, but it means execute() never returns in watch mode. The only exit path is process.exit(0) inside the SIGINT handler. This makes unit-testing watch mode very difficult (no way to programmatically stop it). Consider accepting an AbortSignal or returning the interval handle for testability.


214-215: totalChanges is computed but never used.

This variable is dead code. Remove it or wire it into the summary output.

Proposed fix
-    const totalChanges =
-      changesData.draft.length + changesData.active.length + changesData.completed.length;

228-231: No-op forEach on completed changes.

This loop body is empty. Either remove it or implement the intended logic (e.g., accumulating task counts from completed changes into totalTasks/completedTasks).

Proposed fix — remove the no-op loop
-    changesData.completed.forEach(() => {
-      // Completed changes count as 100% done (we don't know exact task count)
-      // This is a simplification
-    });

53-121: Inconsistent output assembly: append vs direct output +=.

Lines 118–119 concatenate directly to output (skipping append), so they don't get the trailing \n that append adds. Meanwhile, getSummaryOutput (Line 65) is also concatenated directly. The mixing is easy to misread and could introduce subtle double-newline or missing-newline issues during future edits. Consider using append consistently, or document the intent.

test/core/view.test.ts (1)

9-128: No tests cover watch mode.

The watch mode path (options.watch = true) is untestable with the current design since execute never returns. Consider adding a note/TODO, or refactoring execute to accept an AbortSignal so watch mode can be stopped programmatically in tests.

- Fix SIGINT listener leak by using process.once
- Improve error visibility in watch mode by clearing screen and showing errors in red
- Remove dead code (unused totalChanges and empty loop)
- Standardize string construction for consistent output
- Add AbortSignal support and unit tests for watch mode
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/core/view.ts`:
- Line 27: The code currently clears the terminal including scrollback by
writing '\x1B[3J' in the process.stdout.write calls; update both occurrences in
src/core/view.ts (the process.stdout.write(...) calls that build the clear
sequence) to remove the '\x1B[3J' token so the sequence becomes only
'\x1B[2J\x1B[H' + output, thereby clearing the visible screen and repositioning
the cursor while preserving terminal scrollback.
🧹 Nitpick comments (3)
src/core/view.ts (3)

39-39: Async callback in setInterval can cause overlapping executions.

setInterval(update, 2000) fires every 2 s regardless of whether the previous update() (which is async) has finished. If getDashboardOutput ever takes longer than 2 s (e.g., large project, slow disk), two invocations will race and interleave process.stdout.write calls, producing garbled output.

A simple fix is to use a recursive setTimeout instead:

Proposed fix
-      const interval = setInterval(update, 2000);
+      let timeout: ReturnType<typeof setTimeout>;
+      const scheduleNext = () => {
+        timeout = setTimeout(async () => {
+          await update();
+          scheduleNext();
+        }, 2000);
+      };
+      scheduleNext();

Adjust cleanup references from clearInterval(interval) to clearTimeout(timeout) accordingly.


60-69: Never-resolving promise on Line 68 keeps the function from ever returning.

When no AbortSignal is provided, await new Promise(() => {}) hangs forever — the only escape is process.exit(0) inside the SIGINT handler. This is acceptable for CLI entry points but makes the method impossible to use programmatically or in integration tests without an AbortSignal.

Consider documenting this contract explicitly (e.g., a JSDoc note that callers must pass signal if they need the function to return), or provide a fallback that resolves when SIGINT fires instead of calling process.exit:

Sketch
      } else {
-        await new Promise(() => {});
+        await new Promise<void>((resolve) => {
+          const onSigint = () => {
+            clearInterval(interval);
+            console.log('\nExiting watch mode...');
+            resolve();
+          };
+          process.once('SIGINT', onSigint);
+        });
       }

This would unify the exit path and let the function return cleanly without process.exit.


41-47: process.exit(0) in cleanup is a hard exit that bypasses any callers awaiting execute.

When SIGINT fires (without an AbortSignal), the cleanup handler calls process.exit(0), which immediately terminates the process. Any finally blocks or post-execute logic in the caller will never run. This is related to the never-resolving promise on Line 68 — if the promise were made resolvable on SIGINT (as suggested above), the process.exit here becomes unnecessary and cleanup becomes more composable.

if (output !== lastOutput) {
lastOutput = output;
// Clear screen, scrollback and move cursor to home
process.stdout.write('\x1B[2J\x1B[3J\x1B[H' + output);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

\x1B[3J clears terminal scrollback history.

The PR description states the goal is to "preserve terminal scrolling" and avoid forcing scroll, but \x1B[3J explicitly erases the scrollback buffer. If the intent is only to clear the visible screen and reposition the cursor, drop the \x1B[3J sequence and keep only \x1B[2J\x1B[H. Same applies to Line 32.

🤖 Prompt for AI Agents
In `@src/core/view.ts` at line 27, The code currently clears the terminal
including scrollback by writing '\x1B[3J' in the process.stdout.write calls;
update both occurrences in src/core/view.ts (the process.stdout.write(...) calls
that build the clear sequence) to remove the '\x1B[3J' token so the sequence
becomes only '\x1B[2J\x1B[H' + output, thereby clearing the visible screen and
repositioning the cursor while preserving terminal scrollback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant